도커빌드 시간을 3분의 1로 줄여보았다. Part 1

📅 2021. 03. 29

스마트스터디는 도커 이미지를 빌드해서 AWS ECS에 배포하는 방식을 사용하고 있습니다. 20분에 가까운 빌드 시간을 어떻게 6분으로 줄일 수 있었는지 얘기해보려고 합니다.

이미지 용량 줄이기

빌드 시간을 줄이는 방법의 핵심은 이미지를 최대한 가볍게 하는 것입니다. 아래와 같은 방법으로 이미지 용량을 줄일 수 있습니다.

  • Slim, Alpine 등 가벼운 베이스 이미지 사용하기
  • Multi-stage build
  • package.json에서 devDependencies 를 잘 정의하기

Slim, Alpine 등 가벼운 베이스 이미지 사용하기

Node.js 공식 도커 허브를 접속해보면 Supported tags 목차에서 다음과 같은 태그들을 볼 수 있습니다.

  • 14-buster, 14-stretch, 14-buster-slim, 14-alpine

태그가 굉장히 다양해서 머리가 아프실 수도 있겠습니다. 어떤 이미지를 골라야 하는지 하나하나 분석해봅시다.

Buster, Stretch.. 🤔?

Debian Releases

데비안 운영체제는 릴리즈 명칭에 Buster, Stretch와 같이 코드네임을 붙여서 버전을 관리하고 있습니다. 위 사이트에 가보시면 현재 안정적인 버전과 과거의 버전들을 확인할 수 있는데요. “현재 안정 배포판”이라고 돼있는 버전을 사용하시는 걸 권장합니다. (현재는 Buster)

Full vs Slim vs Alpine

사실 이미지 용량을 줄여야 하는 입장에서 가장 중요한 선택이 되는 부분인데요. 이미지 용량으로 보면 Alpine이 약 38MB로 Full Image(333MB), Slim Image(62MB)에 비해서 가장 작은 용량을 갖고 있습니다.

Full -> Slim -> Alpine으로 갈수록 내장된 패키지가 줄어들기 때문에 용량도 감소합니다. Full 이미지의 구성을 대략적으로 보면 다음과 같습니다.

  • make, gcc, g++등과 같은 라이브러리/빌드 툴
  • 버전 관리 소프트웨어 (Git)
  • imagemagicklib로 시작하는 라이브러리들

Slim 이미지는 Full 이미지에서 Node, 혹은 Python와 같은 특정 소프트웨어를 실행하기 위한 최소한의 패키지만 남긴 이미지입니다.

“오, 그럼 저는 안정적이고 가장 가벼운 14-buster-alpine 으로 할게요"

-삑- [ERROR] Docker Image Not Found

조금 서두르셨네요. buster-alpine이라는 이미지는 없습니다!

Alpine이 가장 가벼운 이미지인 것은 맞지만, Full 혹은 Slim 이미지와는 다른 점이 있습니다. 바로 Debian 운영체제가 아니라는 점이지요. Alpine 이미지는 알파인 리눅스(https://alpinelinux.org/)를 기반으로 배포된 이미지입니다. 그래서 데비안과 다르게 buster, jessie와 같은 코드네임이 없고 3.12.1 과같은 Semantic Versioning 규칙을 사용해서 배포를 하고 있습니다.

이미지를 고를 때 좋은 전략은 Alpine 이미지로 이미지를 먼저 빌드해보고, 에러가 나면 Slim 이미지를 써보고 그래도 에러가 지속되면 Full 이미지를 사용하는 방법입니다.

Full 이미지로 바꾸는 대신 Alpine에서 필요한 패키지 설치하기

Alpine 이미지를 유지하고 에러를 해결하는 방법도 있습니다. 예를 들어 노드 패키지 중에 node-gyp 를 디펜던시로 하는 패키지가 있는 경우 빌드 중에 에러가 발생할 수 있는데요. 그럴 땐 빌드에 필요한 패키지를 설치하는 레이어를 추가해서 문제를 해결할 수 있습니다.

FROM node:alpine

RUN apk add --no-cache --virtual .gyp python make g++ \
  && npm install [ your npm dependencies here ] \
  && apk del .gyp

비단 node-gyp 뿐만 아니라 리눅스 패키지의 부재로 발생하는 에러의 경우 필요한 패키지를 설치해줌으로써 대부분 문제를 해결할 수 있습니다. 에러 발생시 해당 패키지의 공식 사이트 확인 혹은 구글링을 해보면 어떤 패키지가 필요한지 쉽게 찾을 수 있습니다. 다만 이 문제를 해결하기 위해 너무 오랜 시간을 사용하셨다면 slim 혹은 full 이미지로 가는 게 정신건강에 이로울 수도 있습니다(..)

Multi-stage build

이미지 용량을 효과적으로 줄이는 방법 중 하나로, 한 Dockerfile에 여러 단계의 빌드를 실행 후 최종 결과물만 이미지에 저장하는 전략입니다.

다음은 Next.js 이미지를 만들기 위해 실제로 사용한 Dockerfile 예제입니다.

# 프론트 환경 구성
FROM mhart/alpine-node:14 AS builder

# apt 필수 패키지 설치
# @sentry/webpack-plugin의 source map 업로드가 curl로 업로드함
RUN apk --no-cache add curl

WORKDIR /app
COPY    package.json  .
COPY    yarn.lock  .
RUN     yarn install
COPY    .  .
RUN     yarn build
RUN     yarn deploy
RUN     yarn install --production

# 최종 환경 구성
FROM mhart/alpine-node:14
WORKDIR /app
# 필수요소 복사
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["yarn", "start"]

일반적인 Dockerfile과 차이점이 보이시나요? Dockerfile에서 FROM이 두 개 이상 있으면 multi-stage build를 사용했다고 보시면 됩니다.

# 프론트 환경 구성
FROM mhart/alpine-node:14 AS builder

FROM 뒤에 AS를 붙이면 다음 스테이지의 빌드에서 참조가 가능합니다. 이 부분은 아래에서 계속 설명하도록 하겠습니다.

# apt 필수 패키지 설치
# @sentry/webpack-plugin의 source map 업로드가 curl로 업로드함
RUN apk --no-cache add curl

스마트스터디는 프론트엔드에서 발생하는 오류를 추적하기 위해 Sentry를 사용하고 있는데요. 새로운 도커파일을 빌드할 때마다 @sentry/webpack-plugin를 통해서 Sentry 서버에 릴리즈 파일을 업로드 하는 과정이 필요합니다. 업로드를 위해선 curl 패키지가 설치되어있어야 하기 때문에 빌드를 하기 전에 별도로 설치해주는 명령어를 실행하고 있습니다. 최종 결과물에선 빌드된 파일만 남기기 위해서 curl 패키지는 제거됩니다.

WORKDIR /app
COPY    package.json  .
COPY    yarn.lock  .
RUN     yarn install
COPY    .  .
RUN     yarn build
RUN     yarn deploy
RUN     yarn install --production

yarn install을 두 번 실행하는 것에 조금 의아하실 수도 있을 것 같습니다. 이 부분은 package.jsondevDependencies 와 관련이 있습니다. 기본적으로 yarn install 을 실행하면 dependenciesdevDependencies 에 정의된 모든 패키지를 설치합니다. 프로덕션 환경에서는 devDependencies 에 정의된 패키지가 굳이 필요 없겠지요. yarn buildyarn deploy 단계에서는 devDependencies 의 패키지가 사용되기 때문에 모든 패키지가 설치된 상태에서 스크립트를 실행했습니다. 마지막으로 --production 플래그를 붙여서 다시 설치를 해주면 프로덕션에 필요한 패키지만 남기고 불필요한 패키지는 자동으로 정리됩니다.

# 최종 환경 구성
FROM mhart/alpine-node:14
WORKDIR /app
# 필수요소 복사
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/node_modules ./node_modules

multi-stage 빌드가 이루어지는 핵심 파트입니다. COPY 명령어에서 --from 옵션을 쓰면 해당 이미지의 경로를 참조할 수 있는데요. 저희가 처음에 FROM mhart/alpine-node:14 AS builder 라고 별명을 붙여줬기 때문에 builder 이미지의 내용물을 현재 이미지에 복사할 수 있습니다. 덕분에 실제 앱 구동에 필요한 결과물만 쏙쏙 골라와서 최종 이미지로 남길 수 있는 것이지요.

참고로 https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile 에 가면 Next.js에서 Multi-stage 빌드를 구성하는 예제 코드가 있습니다. 저도 처음 Next.js Dockerfile을 구성할 때 해당 도커파일을 기준으로 작업했었습니다. 지금은 업데이트되어서 구성이 조금 달라졌네요. 수시로 업데이트가 이루어지니 한 번 코드를 분석해보고 본인의 프로젝트에 맞게 변경해보는 것도 도움이 될 듯합니다.

package.json에서devDependencies를 잘 정의하기

패키지를 설치하면서 간과할 수 있는 실수 중 하나가 모든 패키지를 dev 구분없이 설치하는 것입니다. npm install --save-dev 혹은 yarn add -D 옵션을 사용하면 ‘개발환경에서만 이 패키지를 사용할 것이다’ 라고 devDependencies 에 별도로 정의해줄 수 있습니다. 이렇게 개발용 디펜던시를 구분하면 동료가 package.json 을 봤을 때 호환성 패키지를 파악하는 일이 훨씬 수월해집니다. 뿐만 아니라 앞서 multi-stage build에서 설명했듯이 yarn install --proudction 과 같은 명령어를 실행할 때 프로덕션에 필요한 패키지만 골라서 설치할 수 있게 됩니다. 예를 들어 린트 도구인 eslint 혹은 테스트용 패키지인 jest 와 같은 패키지는 프로덕션에서는 사용할 일이 없겠지요. 이런 패키지들은 devDependencies에 넣는 것이 좋습니다.

지금까지 도커 이미지 용량을 줄이는 다양한 전략에 대해서 알아봤습니다. 여러가지 많이 적긴 했지만 사실 베이스 이미지를 Alpine으로만 바꾸셔도 체감 효과가 굉장히 클 거라고 생각합니다. (거의 용량이 1/6로 줄어드니..)

다음편은 도커파일을 Backend / Frontend / Nginx 세 가지로 빌드해서 빌드 시간을 획기적으로 줄인 경험에 대해서 소개해보도록 하겠습니다.

Medium에서 보기